Passed
Push — main ( e58f84...db8f8b )
by Eduardo
02:28
created

SoFloC.ts ➔ getXmlContentFromZip   A

Complexity

Conditions 1

Size

Total Lines 1
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 1
dl 0
loc 1
ccs 1
cts 1
cp 1
rs 10
c 0
b 0
f 0
cc 1
crap 1
1 1
import { randomUUID } from 'crypto'
2 1
import JSZip from 'jszip'
3 1
import { xml2js } from 'xml-js'
4
import { CustomisationsXml } from './customisations'
5
import { SolutionXml } from './solution'
6
import { Base64, FileInput, FlowCopyT, PrivateWorkflowT, WorkflowT, Xml } from './types'
7
8 1
export * from './types'
9
10 1
export class SoFloC {
11
  /**
12
   * Creates a new SoFloC instance. To be able to use it you need to run `await soFloC.load()`
13
   * @param file The file data to be open
14
   * @param name The name of the file
15
   */
16 22
  constructor (file: FileInput, name: string) {
17 22
    this.#wasLoaded = false
18 22
    this.#file = file
19 22
    this.name = name
20
  }
21
22
  /**
23
   * Loads a ***Solution*** zip file and make it ready to get the existing flows and the version, copy flows and update the version. Sets #wasLoaded to true
24
   */
25 25
  async load () {
26 25
    if (!this.#wasLoaded) {
27 21
      this.#zip = await this.#unzip(this.#file)
28
29 19
      const [customisations, customisationsData] = await this.#getCustomisations(this.#zip)
30 18
      this.#customisations = customisations
31 18
      this.#customisationsData = customisationsData
32
33 18
      const [solution, solutionData] = await this.#getSolution(this.#zip)
34 17
      this.#solution = solution
35 17
      this.#solutionData = solutionData
36
37 17
      this.version = this.#getCurrentVersion(this.#solutionData)
38 16
      this.originalVersion = this.version
39
40 16
      this.#workflows = this.#getWorkflows(this.#customisationsData, this.#solutionData, this.#zip)
41 16
      this.data = await this.#getData(this.#zip)
42
43 16
      this.#wasLoaded = true
44
    }
45
  }
46
47
  /**
48
   * Copies a flow in the ***Solution***.
49
   * @param flowGuid The GUID of the flow to be copied
50
   * @param newFlowName The name of the copy
51
   * @param newVersion The new ***Solution*** version
52
   */
53 7
  async copyFlow (flowGuid: string, newFlowName: string, newVersion?: string) {
54 7
    await this.load()
55 6
    this.#worflowExists(flowGuid)
56
57 3
    if (newVersion) await this.updateVersion(newVersion)
58
59 3
    const copyData = this.#getCopyData(newFlowName)
60
61 3
    const [customisations, customisationsData] = this.#copyOnCustomisations(flowGuid, copyData)
62 3
    this.#customisations = customisations
63 3
    this.#customisationsData = customisationsData
64
65 3
    const [solution, solutionData] = this.#copyOnSolution(flowGuid, copyData)
66 3
    this.#solution = solution
67 3
    this.#solutionData = solutionData
68
69 3
    await this.#copyFile(flowGuid, copyData)
70
  }
71
72
  /**
73
   * Deletes a flow in the ***Solution***.
74
   * @param flowGuid The GUID of the flow to be copied
75
   */
76 5
  async deleteFlow (flowGuid: string) {
77 5
    await this.load()
78 4
    this.#worflowExists(flowGuid)
79
80 1
    const [customisations, customisationsData] = this.#deleteOnCustomisations(flowGuid)
81 1
    this.#customisations = customisations
82 1
    this.#customisationsData = customisationsData
83
84 1
    const [solution, solutionData] = this.#deleteOnSolution(flowGuid)
85 1
    this.#solution = solution
86 1
    this.#solutionData = solutionData
87
88 1
    await this.#deleteFile(flowGuid)
89
  }
90
91
  /**
92
   * Updates the ***Solution*** version. The new version must be bigger than the previous.
93
   * @param newVersion The new ***Solution*** version
94
   */
95 13
  async updateVersion (newVersion: string) {
96 13
    await this.load()
97 10
    this.validateVersion(newVersion)
98
99 6
    this.name = this.name
100
      .replace(this.#snake(this.version), this.#snake(newVersion))
101 6
    this.#solution = this.#solution
102
      .replace(`<Version>${this.version}</Version>`, `<Version>${newVersion}</Version>`)
103 6
    this.version = newVersion
104
  }
105
106
  /**
107
   * The list of workflows in the solution. To be able to get the list you need to run `await soFloC.load()` first.
108
   */
109 4
  get workflows () {
110 4
    if (!this.#wasLoaded) return []
111 8
    return this.#workflows.map(workflow => ({
112
      name: workflow.name,
113
      id:   workflow.id,
114
    })) as WorkflowT[]
115
  }
116
117
  /* #region LOAD METHODS */
118
  /**
119
   * Resets the loaded data
120
   */
121
  /**
122
   * Retrieves the ***Solution*** zip content
123
   * @param file The ***Solution*** zip file (base64, string, text, binarystring, array, uint8array, arraybuffer, blob or stream)
124
   */
125
  async #unzip (file: FileInput) {
126 21
    try {
127 21
      const options = typeof file === 'string'
128
        ? { base64: true }
129
        : {}
130 21
      return await JSZip.loadAsync(file, options)
131
    } catch (error) {
132 2
      console.log(error)
133 2
      throw new Error('Failed to unzip the file')
134
    }
135
  }
136
137
  /**
138
   * Retrieves the customization.xml string
139
   * @param zip The ***Solution*** JSZip content
140
   */
141
  async #getCustomisations (zip: JSZip): Promise<[Xml, CustomisationsXml]> {
142 19
    return (await this.#getXmlContentFromZip('customizations', zip)) as [Xml, CustomisationsXml]
143
  }
144
145
  /**
146
   * Retrieves the customization.xml string
147
   * @param zip The ***Solution*** JSZip content
148
   */
149
  async #getSolution (zip: JSZip): Promise<[Xml, SolutionXml]> {
150 18
    return (await this.#getXmlContentFromZip('solution', zip)) as [Xml, SolutionXml]
151
  }
152
153
  /**
154
   * Retrieves a XML from the ***Solution*** zip.
155
   * @param xmlName The name of the XML to be retrieved (without extension)
156
   * @returns The string content of the XML
157
   */
158
  async #getXmlContentFromZip (xmlName: string, zipContents: JSZip): Promise<[Xml, CustomisationsXml | SolutionXml]> {
159 37
    try {
160 37
      const file = zipContents.files[`${xmlName}.xml`]
161 37
      const xml = await file.async('string')
162 35
      const data = xml2js(xml, { compact: true }) as CustomisationsXml
163
164 35
      return [
165
        xml,
166
        data,
167
      ]
168
    } catch (error) {
169 2
      console.log(error)
170 2
      throw new Error(`'${xmlName}.xml' was not found in the Solution zip`)
171
    }
172
  }
173
174
  /**
175
   * Retrieves the ***Solution*** current version from solution.xml
176
   * @param solution The solution.xml
177
   */
178
  #getCurrentVersion (solution: SolutionXml) {
179 17
    try {
180 17
      return solution.ImportExportXml.SolutionManifest.Version._text
181
    } catch (error) {
182 1
      console.log(error)
183 1
      throw new Error('Failed to retrieve the version')
184
    }
185
  }
186
187
  /**
188
   * Retrieves the list of workflows found in the ***Solution*** zip
189
   * @param customisations The customizations.xml
190
   * @param zip The ***Solution*** JSZip content
191
   * @returns The workflows list
192
   */
193
  #getWorkflows (customisations: CustomisationsXml, solution: SolutionXml, zip: JSZip) {
194 43
    const workflowFiles = Object.entries(zip.files).filter(([name]) => name.match(/Workflows\/.+\.json/)).map(file => file[1])
195
196 20
    const wfs = Array.isArray(customisations.ImportExportXml.Workflows.Workflow)
197
      ? customisations.ImportExportXml.Workflows.Workflow
198
      : [customisations.ImportExportXml.Workflows.Workflow]
199 20
    const workflows = wfs
200 45
      .map(workflow => {
201 45
        const id = workflow._attributes.WorkflowId.replace(/{|}/g, '')
202 45
        const rcs = Array.isArray(solution.ImportExportXml.SolutionManifest.RootComponents.RootComponent)
203
          ? solution.ImportExportXml.SolutionManifest.RootComponents.RootComponent
204
          : [solution.ImportExportXml.SolutionManifest.RootComponents.RootComponent]
205 84
        const isOnSolution = rcs.findIndex(wf => wf._attributes.id.includes(id)) >= 0
206 80
        const file = workflowFiles.find(workflowFile => workflowFile.name.includes(id.toUpperCase())) as JSZip.JSZipObject
207 45
        return !!file && !!id && isOnSolution
208
          ? {
209
              name: workflow._attributes.Name,
210
              id,
211
              file,
212
            }
213
          : null
214
      })
215 45
    return workflows.filter(workflow => workflow !== null) as PrivateWorkflowT[]
216
  }
217
218
  /**
219
   * Retrieves the zip data
220
   * @param zip The ***Solution*** zip
221
   * @returns The generated base64 zip
222
   */
223
  async #getData (zip: JSZip) {
224 20
    return await zip.generateAsync({
225
      type:               'base64',
226
      compression:        'DEFLATE',
227
      compressionOptions: {
228
        level: 9,
229
      },
230
    })
231
  }
232
  /* #endregion */
233
234
  /* #region COPY FLOW METHODS */
235
  /**
236
   * Retrieves an object containing the information of the flow copy
237
   * @param newFlowName The name of the flow copy
238
   * @returns The flow copy data
239
   */
240
  #getCopyData (newFlowName: string) {
241 3
    const guid = randomUUID()
242 3
    const upperGuid = guid.toUpperCase()
243 3
    const fileName = `Workflows/${newFlowName.replace(/\s/g, '')}-${upperGuid}.json`
244
245 3
    return {
246
      guid,
247
      upperGuid,
248
      name: newFlowName,
249
      fileName,
250
    }
251
  }
252
253
  /**
254
   * Copies the flow inside the customizations.xml
255
   * @param flowGuid The GUID of the original flow to be copied
256
   * @param copyData The data of the flow copy
257
   * @returns The customisations.xml data
258
   */
259
  #copyOnCustomisations (flowGuid: string, copyData: FlowCopyT): [Xml, CustomisationsXml] {
260 3
    const workflowComponent = `<Workflow WorkflowId="{${flowGuid}}" Name=".+?">(.|\r|\n)+?<\/Workflow>`
261 3
    const workflowRegEx = new RegExp(`\r?\n?.+?${workflowComponent}`, 'gm')
262
263 3
    const workflow = this.#customisations.match(workflowRegEx)?.[0] as string
264
265 3
    const jsonFileNameRegEx = /<JsonFileName>(.|\r|\n)+?<\/JsonFileName>/gi
266 3
    const introducedVersionRegEx = /<IntroducedVersion>(.|\r|\n)+?<\/IntroducedVersion>/gi
267
268 3
    const copy = workflow
269
      .replace(flowGuid, copyData.guid)
270
      .replace(/Name=".+?"/, `Name="${copyData.name}"`)
271
      .replace(jsonFileNameRegEx, `<JsonFileName>/${copyData.fileName}</JsonFileName>`)
272
      .replace(introducedVersionRegEx, `<IntroducedVersion>${this.version}</IntroducedVersion>`)
273
274 3
    const customisations = this.#customisations.replace(workflow, `${workflow}${copy}`)
275 3
    const data = xml2js(customisations, { compact: true }) as CustomisationsXml
276
277 3
    return [
278
      customisations,
279
      data,
280
    ]
281
  }
282
283
  /**
284
   * Copies the flow inside solution.xml
285
   * @param flowGuid The GUID of the original flow to be copied
286
   * @param copyData The data of the flow copy
287
   * @returns The solution.xml data
288
   */
289
  #copyOnSolution (flowGuid: string, copyData: FlowCopyT): [Xml, SolutionXml] {
290 3
    const rootComponent = `<RootComponent type="29" id="{${flowGuid}}" behavior="0" />`
291 3
    const rootRegEx = new RegExp(`\r?\n?.+?${rootComponent}`, 'gm')
292
293 3
    const root = this.#solution.match(rootRegEx)?.[0] as string
294
295 3
    const copy = root
296
      .replace(flowGuid, copyData.guid)
297
298 3
    const solution = this.#solution
299
      .replace(root, `${root}${copy}`)
300 3
    const data = xml2js(solution, { compact: true }) as SolutionXml
301
302 3
    return [
303
      solution,
304
      data,
305
    ]
306
  }
307
308
  /**
309
   * Copies the flow inside the ***Solution*** zip and updates data and #workflows properties
310
   * @param flowGuid The GUID of the original flow to be copied
311
   * @param copyData The data of the flow copy
312
   */
313
  async #copyFile (flowGuid: string, copyData: FlowCopyT) {
314 4
    const fileToCopy = this.#workflows.find(wf => wf.id === flowGuid.toLowerCase()) as PrivateWorkflowT
315
316 3
    this.#zip.file(copyData.fileName, await fileToCopy.file.async('string'))
317 3
    this.#zip.file('solution.xml', this.#solution)
318 3
    this.#zip.file('customizations.xml', this.#customisations)
319
320 3
    this.data = await this.#getData(this.#zip)
321 3
    this.#workflows = this.#getWorkflows(this.#customisationsData, this.#solutionData, this.#zip)
322
  }
323
  /* #endregion */
324
325
  /* #region DEPLETE FLOW METHODS */
326
  /**
327
   * Deletes the flow inside the customizations.xml
328
   * @param flowGuid The GUID of the flow to be deleted
329
   * @returns The customisations.xml data
330
   */
331
  #deleteOnCustomisations (flowGuid: string): [Xml, CustomisationsXml] {
332 1
    const workflowComponent = `<Workflow WorkflowId="{${flowGuid}}" Name=".+?">(.|\r|\n)+?<\/Workflow>`
333 1
    const workflowRegEx = new RegExp(`\r?\n?.+?${workflowComponent}`, 'gm')
334
335 1
    const workflow = this.#customisations.match(workflowRegEx)?.[0] as string
336
337 1
    const customisations = this.#customisations.replace(workflow, '')
338 1
    const data = xml2js(customisations, { compact: true }) as CustomisationsXml
339
340 1
    return [
341
      customisations,
342
      data,
343
    ]
344
  }
345
346
  /**
347
   * Deletes the flow inside solution.xml
348
   * @param flowGuid The GUID of the flow to be deleted
349
   * @returns The solution.xml data
350
   */
351
  #deleteOnSolution (flowGuid: string): [Xml, SolutionXml] {
352 1
    const rootComponent = `<RootComponent type="29" id="{${flowGuid}}" behavior="0" />`
353 1
    const rootRegEx = new RegExp(`\r?\n?.+?${rootComponent}`, 'gm')
354
355 1
    const root = this.#solution.match(rootRegEx)?.[0] as string
356
357 1
    const solution = this.#solution.replace(root, '')
358 1
    const data = xml2js(solution, { compact: true }) as SolutionXml
359
360 1
    return [
361
      solution,
362
      data,
363
    ]
364
  }
365
366
  /**
367
   * Deletes the flow inside the ***Solution*** zip and updates data and #workflows properties
368
   * @param flowGuid The GUID of the flow to be deleted
369
   */
370
  async #deleteFile (flowGuid: string) {
371 2
    const fileToDelete = this.#workflows.find(wf => wf.id === flowGuid.toLowerCase()) as PrivateWorkflowT
372
373 1
    this.#zip.remove(fileToDelete.file.name)
374 1
    this.#zip.file('solution.xml', this.#solution)
375 1
    this.#zip.file('customizations.xml', this.#customisations)
376
377 1
    this.data = await this.#getData(this.#zip)
378 1
    this.#workflows = this.#getWorkflows(this.#customisationsData, this.#solutionData, this.#zip)
379
  }
380
  /* #endregion */
381
382
  /* #region UPDATE VERION METHODS */
383
  /**
384
   * Validates if the new version is valid
385
   * @param newVersion The new ***Solution*** version
386
   */
387 10
  validateVersion (newVersion: string) {
388 10
    const validRegEx = /^((\d+\.)+\d+)$/
389 10
    if (!validRegEx.exec(newVersion)) {
390 1
      throw new Error(`Version '${newVersion}' is not valid. It should follow the format <major>.<minor>.<?build>.<?revision>.`)
391
    }
392
393 36
    const originalVersionValues = this.originalVersion.split('.').map(value => Number(value))
394 28
    const newVersionValues = newVersion.split('.').map(value => Number(value))
395
396 9
    let currentValueString = ''
397 9
    let newValueString = ''
398 9
    for (let i = 0; i < originalVersionValues.length; i++) {
399 36
      const currentValue = originalVersionValues[i] || 0
400 36
      const newValue = newVersionValues[i] || 0
401
402 36
      const currentValueLength = String(currentValue).length
403 36
      const newValueLength = String(newValue).length
404
405 36
      const maxLength = Math.max(currentValueLength, newValueLength)
406
407 36
      currentValueString += '0'.repeat(maxLength - currentValueLength) + String(currentValue)
408 36
      newValueString += '0'.repeat(maxLength - newValueLength) + String(newValue)
409
    }
410
411 9
    if (Number(newValueString) < Number(currentValueString) ||
412 3
    (Number(newValueString) === Number(currentValueString) && newVersion !== this.originalVersion)) throw new Error(`Version '${newVersion}' is smaller than '${this.originalVersion}'`)
413
  }
414
  /* #endregion */
415
416
  /* #region  GENERAL METHODS */
417
  /**
418
   * Verifies if a specified workflow exists in the ***Solution***
419
   */
420
  #worflowExists (flowGuid: string) {
421 12
    if (this.#workflows.findIndex(wf => wf.id === flowGuid) < 0) throw new Error(`Workflow file with GUID '${flowGuid}' does not exist in this Solution or the Solution was changed without updating 'solution.xml' or 'customizations.xml'`)
422
  }
423
424
  /**
425
   * Retrieves the version replacing '.' to '_'
426
   * @param version The version to be converted to snake_case
427
   * @returns
428
   */
429
  #snake (version: string) {
430 12
    return version.replaceAll('.', '_')
431
  }
432
  /* #endregion */
433
434
  /* #region CLASS PROPERTIES */
435
  #file: FileInput
436
  #zip: JSZip
437
  /**
438
   * The ***Solution*** file name. It is update as a new version is set.
439
   */
440
  name: string
441
  /**
442
   * The ***Solution*** version. It is update as a new version is set.
443
   */
444
  version: string
445
  /**
446
   * The ***Solution*** data as Base64. It is updated as new copies are added.
447
   */
448
  data: Base64
449
  /**
450
   * The ***Solution*** version as it was when the file was loaded. It does not change when a new version is set.
451
   */
452
  originalVersion: string
453
  #workflows: PrivateWorkflowT[]
454
  #customisations: Xml
455
  #customisationsData: CustomisationsXml
456
  #solution: Xml
457
  #solutionData: SolutionXml
458 22
  #wasLoaded = false
459
460
  // TODO UndoStack
461
462
  /* #endregion */
463
}
464